Rails advanced routing constraints

Steve Polito
Edited by thoughtbot

I’m on a client project that’s using Devise. In an effort to prevent anonymous users from accessing admin routes, we wrap those routes with an authenticated constraint. This constraint also ensures only authenticated users who are admins are allowed access.

# config/routes.rb

authenticated :user, -> { _1.admin? } do
  namespace :admin do
    resources :users
  end
end

We recently needed to restrict access to the admin routes based on IP address. Our first approach was to place this logic at the controller layer and use a before_action filter.

# app/controllers/admin/users_controller.rb

class Admin::UsersController < ApplicationController
  before_action :authorize_ip

  private

  def authorize_ip
    allow_list = Rails.application.config.x.ips.allow_list

    raise ActionController::RoutingError.new("Not Found") unless requests.ip.in? allow_list
  end
end

However, we realized there was an opportunity to push this logic to the routing layer, since we already have access to the request object. This saves us from having to process the request in a controller altogether, which is a small performance gain.

Create a custom constraint

Although Rails provides the ability to restrict routes based on IP range, we needed to create a custom constraint in order to see if the IP was in our allow list, which is not possible otherwise.

The custom constraint needs to respond to matches? when passed a request, and must return a boolean.

To see if a request is from an IP address in our allow list, we can do something like this:

# app/constraints/ip_constraint.rb

class IpConstraint
  def self.matches?(request)
    allow_list = Rails.application.config.x.ips.allow_list

    request.ip.in? allow_list
  end
end

Then we can wrap our admin routes in this constraint like so.

# config/routes.rb

authenticated :user, -> { _1.admin? } do
  constraints(IpConstraint) do
    namespace :admin do
      resources :users
    end
  end
end

Consolidating constraints

Although the previous implementation is perfectly acceptable, there’s an opportunity to consolidate the authenticated constraint with our IpConstraint.

Since we need access to the user, we can leverage warden (which is a dependency of Devise) to return the user object from the request.

requst.env["warden"].user

# => #<User>

We can then combine this with the logic used to check the IP address like so:

# app/constraints/admin_constraint.rb

class AdminConstraint
  attr_reader :user, :ip

  def initialize(request)
    @user = request.env["warden"].user
    @ip = request.ip
  end

  def self.matches?(request)
    new(request).authorized?
  end

  def authorized?
    allow_list = Rails.application.config.x.ips.allow_list

    ip.in?(allow_list) && user.present? && user.admin?
  end
end

A constraint needs to respond to matches?, so we are free to put whatever logic we want in that method so long as it returns boolean. In this case, our matches? method initializes a new instance of our constraint and calls authorized?. The authorized? method is responsible for determining if the request came from a supported IP address, and that the requested came from an authenticated admin.

Now we can update our routes like so:

# config/routes.rb

constraints(AdminConstraint) do
  namespace :admin do
    resources :users
  end
end

Wrapping up

I think this is an appropriate strategy for authorizing requests at the routing layer (instead of the controller layer) because it is only concerned with data in the request.

If you need data beyond the raw request, then you should leverage authorization libraries such as Pundit.

Want to learn more?

Learn about the Ruby on Rails services thoughtbot offers and how we can work together to streamline your project.